{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {
    "ein.tags": "worksheet-0",
    "slideshow": {
     "slide_type": "-"
    }
   },
   "source": [
    "# 关注用户\n",
    "\n",
    "本章将实现关注功能，让用户“关注”其他用户，并在首页只显示所关注用户发布的博客文章列表。\n",
    "\n",
    "\n",
    "## 高级自引用多对多关系\n",
    "\n",
    "在数据库中表示用户之间的关注可使用自引用多对多关系，此外还要存储用户关注另一个用户的日期， 以便按照时间顺序列出所有关注者。下面的代码定义了用户关注使用的`Follow`模型：\n",
    "\n",
    "```python\n",
    "# app/models.py\n",
    "import peewee as pw\n",
    "\n",
    "\n",
    "class Follow(db.Model):\n",
    "    follower = pw.ForeignKeyField(User, related_name='followed',\n",
    "                                  on_delete='CASCADE')\n",
    "    followed = pw.ForeignKeyField(User, related_name='followers',\n",
    "                                  on_delete='CASCADE')\n",
    "    timestamp = pw.DateTimeField(default=datetime.utcnow)\n",
    "\n",
    "    class Meta:\n",
    "        db_table = 'follows'\n",
    "        indexes = (\n",
    "            (('follower', 'followed'), True),\n",
    "        )\n",
    "```\n",
    "\n",
    "其中`follower`字段表示关注者，`followed`字段表示被关注者。这两个字段通过`User`模型 进行反向引用，将`Follow`模型中的多对多关系的左右两侧拆分成两个基本的一对多关系， 左侧表示的一对多关系把用户和`follows`表中的一组记录联系起来，用户是关注者。右侧表示的一对多关系把用户和`follows`表中的一组记录联系起来，用户是被关注者。例如， 如果某个用户关注了100个用户，调用`user.followed`后会返回一个可迭代查询对象 （`SelectQuery`） ，每一个迭代实例的`follower`和`followed`回引属性都指向相应的用户。\n",
    "\n",
    "删除对象时，默认的层叠行为是把对象联接的所有相关对象的外键设为空值。但在关联表中，删除记录后正确的行为应该是把指向该记录的实体也删除， 设置参数 `on_delete='CASCADE'` 能有效销毁联接。\n",
    "\n",
    "程序现在要处理两个一对多关系，以便实现多对多关系。在`User`模型中定义用于控制关系 的4个新方法：\n",
    "\n",
    "```python\n",
    "# app/models.py\n",
    "\n",
    "class User(UserMixin, db.Model):\n",
    "    # ...\n",
    "    def follow(self, user):\n",
    "        if not self.is_following(user):\n",
    "            f = Follow(follower=self, followed=user)\n",
    "            f.save()\n",
    "\n",
    "    def unfollow(self, user):\n",
    "        f = self.followed.where(Follow.followed == user.id).first()\n",
    "        if f:\n",
    "            f.delete_instance()\n",
    "\n",
    "    def is_following(self, user):\n",
    "        return self.followed.where(\n",
    "            Follow.followed == user.id).first() is not None\n",
    "\n",
    "    def is_followed_by(self, user):\n",
    "        return self.followers.where(\n",
    "            Follow.follower == user.id).first() is not None\n",
    "```\n",
    "\n",
    "`follow()`方法手动把`Follow`实例插入关联表，从而把关注者和被关注者联接起来， 并让程序设定自定义字段的值。`unfollow()`方法使用`followed`关系找到联接用户和被关注用户的`Follow`实例。若要销毁这两个用户之间的联接，只需删除这个`Follow`对象即可。`is_following()`方法和`is_followed_ by()`方法分别在左右两边的一对多关系中搜索指定用户，如果找到了就返回`True`。\n",
    "\n",
    "为了开启Sqlite3的外键支持，需要在配置文件中添加Peewee的数据库连接参数：\n",
    "\n",
    "```python\n",
    "# config.py\n",
    "class Config(object):\n",
    "    # ...\n",
    "    PEEWEE_CONNECTION_PARAMS = {\n",
    "        'pragmas': [('foreign_keys', 'on')]\n",
    "    }\n",
    "```\n",
    "\n",
    "另外，可以在代码仓库找到对于这个数据库关系的单元测试。\n",
    "\n",
    "**🔖 执行`git checkout 12a`签出程序的这个版本。** ⚠️ 此版本包含数据库迁移。\n",
    "\n",
    "\n",
    "## 显示关注者\n",
    "\n",
    "用户查看一个尚未关注用户的资料页，页面中应显示一个“Follow”（关注）按钮， 如果查看已关注用户的资料页则显示“Unfollow”（取消关注）按钮。\n",
    "\n",
    "页面中最好还能显示出关注者和被关注者的数量，再列出关注和被关注的用户列表， 并在相应的用户资料页中显示“Follows You”（关注了你）的提示。\n",
    "\n",
    "下面是在用户资料页上部添加关注信息的模板：\n",
    "\n",
    "```django\n",
    "{# app/templates/user.html #}\n",
    "{% if current_user.can(Permission.FOLLOW) and user != current_user %}\n",
    "    {% if not current_user.is_following(user) %}\n",
    "        <a href=\"{{ url_for('.follow', username=user.username) }}\"\n",
    "           class=\"btn btn-primary btn-sm\">Follow</a>\n",
    "    {% else %}\n",
    "        <a href=\"{{ url_for('.unfollow', username=user.username) }}\"\n",
    "           class=\"btn btn-danger btn-sm\">Unfollow</a>\n",
    "    {% endif %}\n",
    "{% endif %}\n",
    "\n",
    "<a class=\"btn btn-info btn-sm\" href=\"{{ url_for('.followers', username=user.username) }}\">\n",
    "    Followers <span class=\"badge\">{{ user.followers.count() }}</span>\n",
    "</a>\n",
    "<a class=\"btn btn-warning btn-sm\" href=\"{{ url_for('.followed_by', username=user.username) }}\">\n",
    "    Following <span class=\"badge\">{{ user.followed.count() }}</span>\n",
    "</a>\n",
    "{% if current_user.is_authenticated and user != current_user and user.is_following(current_user) %}\n",
    "    | <span class=\"label label-success\">Follows you</span>\n",
    "{% endif %}\n",
    "```\n",
    "\n",
    "模板用到了4个新端点，分别对应相应的路由：\n",
    "\n",
    "1.  `/follow/<username>`\n",
    "\n",
    "    ```python\n",
    "    # app/main/views.py\n",
    "    @main.route('/follow/<username>')\n",
    "    @login_required\n",
    "    @permission_required(Permission.FOLLOW)\n",
    "    def follow(username):\n",
    "        user = User.select().where(User.username == username).first()\n",
    "        if user is None:\n",
    "            flash('Invalid user.')\n",
    "            return redirect(url_for('.index'))\n",
    "        if current_user.is_following(user):\n",
    "            flash('You are already following this user.')\n",
    "            return redirect(url_for('.user', username=username))\n",
    "        current_user.follow(user)\n",
    "        flash('You are now following %s.' % username)\n",
    "        return redirect(url_for('.user', username=username))\n",
    "    ```\n",
    "\n",
    "    这个视图函数先加载请求的用户，确保用户存在且当前登录用户还没有关注这个用户，然 后调用`User`模型中定义的辅助方法`follow()`，用以联接两个用户。\n",
    "\n",
    "2.  `/unfollow/<username>`\n",
    "\n",
    "    与上面`/follow/<username>`路由类似。\n",
    "\n",
    "3.  `/followers/<username>`\n",
    "\n",
    "    ```python\n",
    "    # app/main/views.py\n",
    "    @main.route('/followers/<username>')\n",
    "    def followers(username):\n",
    "        user = User.select().where(User.username == username).first()\n",
    "        if user is None:\n",
    "            flash('Invalid user.')\n",
    "            return redirect(url_for('.index'))\n",
    "        page = request.args.get('page', 1, type=int)\n",
    "        pagination = Pagination(Follow.followers_of(user),\n",
    "                                current_app.config['FLASKR_FOLLOWERS_PER_PAGE'],\n",
    "                                page,\n",
    "                                check_bounds=False)\n",
    "        follows = [{'user': item.follower, 'timestamp': item.timestamp}\n",
    "                   for item in pagination.items]\n",
    "        return render_template('followers.html', user=user,\n",
    "                               title='Followers of',\n",
    "                               endpoint='.followers', pagination=pagination,\n",
    "                               follows=follows)\n",
    "    ```\n",
    "\n",
    "    这个函数加载并验证请求的用户，然后使用分页显示该用户的 followers 关系。\n",
    "\n",
    "4.  `/followed-by/<username>`\n",
    "\n",
    "    与`/followers/<username>`路由类似。\n",
    "\n",
    "其中`Follow`模型类方法`followers_of`和`followed_by`的实现如下：\n",
    "\n",
    "```python\n",
    "# app/models.py\n",
    "class Follow(db.Model):\n",
    "    # ...\n",
    "    @classmethod\n",
    "    def followers_of(cls, user):\n",
    "        \"\"\"Followers of user.\"\"\"\n",
    "        return (cls.select(cls, User)\n",
    "                .join(User, on=cls.follower)\n",
    "                .where(cls.followed == user))\n",
    "\n",
    "    @classmethod\n",
    "    def followed_by(cls, user):\n",
    "        \"\"\"Followed by user.\"\"\"\n",
    "        return (cls.select(cls, User)\n",
    "                .join(User, on=cls.followed)\n",
    "                .where(Follow.follower == user))\n",
    "```\n",
    "\n",
    "渲染关注者列表的`followers.html`模板能用来渲染关注的用户列表和被关注的用户列表。模板接收的参数包括用户对象、分页链接使用的端点、分页对象和查询结果列表。\n",
    "\n",
    "**🔖 执行`git checkout 12b`签出程序的这个版本。**\n",
    "\n",
    "\n",
    "## 查询所关注用户的文章\n",
    "\n",
    "在程序首页显示用户所关注用户发布的所有文章。这里使用了数据库联结查询，为了提升效率，避免 [N+1查询问题](http://docs.peewee-orm.com/en/latest/peewee/querying.html#avoiding-n-1-queries)，同时查询`Post`和`User`模型，然后进行联结操作，最后进行过滤操作。下面是获取所关注用户的文章的代码示例：\n",
    "\n",
    "```python\n",
    "# app/models.py\n",
    "class User(UserMixin, db.Model):\n",
    "    # ...\n",
    "    @property\n",
    "    def followed_posts(self):\n",
    "        return (Post.select(Post, self.__class__)\n",
    "                .join(Follow, on=(Post.author == Follow.followed))\n",
    "                .join(self.__class__, on=(Post.author == self.__class__.id))\n",
    "                .where(Follow.follower == self))\n",
    "```\n",
    "\n",
    "`followed_posts()`方法定义为属性，调用时无需加`()`。\n",
    "\n",
    "**🔖 执行`git checkout 12c`签出程序的这个版本。**\n",
    "\n",
    "\n",
    "## 显示所关注用户文章\n",
    "\n",
    "现在，用户可以选择在首页显示所有用户的博客文章还是只显示所关注用户的文章。\n",
    "\n",
    "显示所有博客文章或只显示所关注用户的文章的代码如下：\n",
    "\n",
    "```python\n",
    "# app/main/views.py\n",
    "@main.route('/', methods=['GET', 'POST'])\n",
    "def index():\n",
    "    # ...\n",
    "    show_followed = False\n",
    "    if current_user.is_authenticated:\n",
    "        show_followed = bool(request.cookies.get('show_followed', ''))\n",
    "    if show_followed:\n",
    "        query = current_user.followed_posts\n",
    "    else:\n",
    "        query = Post.timeline()\n",
    "    pagination = Pagination(query,\n",
    "                            current_app.config['FLASKR_POSTS_PER_PAGE'],\n",
    "                            check_bounds=False)\n",
    "    posts = pagination.items\n",
    "    return render_template('index.html', form=form, posts=posts,\n",
    "                           show_followed=show_followed, pagination=pagination)\n",
    "```\n",
    "\n",
    "显示所关注用户文章的选项存储在cookie的`show_followed`字段中， 如果其值为非空字符串，则表示只显示所关注用户的文章。cookie以`request.cookies`字典的形式存储在请求对象中。这个cookie的值会转换成布尔值，根据得到的值设定本地变量`query`的值。`query`的值决定最终获取所有博客文章的查询，或是获取过滤后的博客文章查询。\n",
    "\n",
    "`show_followed`cookie在两个新路由中设定：\n",
    "\n",
    "```python\n",
    "# app/main/views.py\n",
    "@main.route('/all')\n",
    "@login_required\n",
    "def show_all():\n",
    "    resp = make_response(redirect(url_for('.index')))\n",
    "    resp.set_cookie('show_followed', '', max_age=30*24*60*60)\n",
    "    return resp\n",
    "\n",
    "\n",
    "@main.route('/followed')\n",
    "@login_required\n",
    "def show_followed():\n",
    "    resp = make_response(redirect(url_for('.index')))\n",
    "    resp.set_cookie('show_followed', '1', max_age=30*24*60*60)\n",
    "    return resp\n",
    "```\n",
    "\n",
    "指向这两个路由的链接添加在首页模板中。点击这两个链接后会为`show_followed`cookie设定适当的值，然后重定向到首页。cookie只能在响应对象中设置，要使用`make_response()`方法创建响应对象。\n",
    "\n",
    "`set_cookie()`函数的前两个参数分别是cookie名和值。可选的`max_age`参数设置 cookie的过期时间，单位为秒。如果不指定参数`max_age`，浏览器关闭后cookie就会过期。在本例中，过期时间为30天。\n",
    "\n",
    "接下来需要对模板进行改动，在页面上部添加两个导航选项卡，分别调用`/all`和`/followed`路由，并在会话中设定正确的值。\n",
    "\n",
    "```django\n",
    "{# app/templates/index.html #}\n",
    "...\n",
    "<div class=\"post-tabs\">\n",
    "    <ul class=\"nav nav-tabs\">\n",
    "        <li{% if not show_followed %} class=\"active\"{% endif %}>\n",
    "            <a href=\"{{ url_for('.show_all') }}\">All</a>\n",
    "        </li>\n",
    "        {% if current_user.is_authenticated %}\n",
    "            <li{% if show_followed %} class=\"active\"{% endif %}>\n",
    "                <a href=\"{{ url_for('.show_followed') }}\">Followers</a>\n",
    "            </li>\n",
    "        {% endif %}\n",
    "    </ul>\n",
    "    {% include '_posts.html' %}\n",
    "</div>\n",
    "...\n",
    "```\n",
    "\n",
    "**🔖 执行`git checkout 12d`签出程序的这个版本。**\n",
    "\n",
    "最后，为了切换所关注用户文章列表时能显示自己的文章，可在构建用户时把用户设为自己的关注者:\n",
    "\n",
    "```python\n",
    "# app/models.py\n",
    "class User(UserMixin, db.Model):\n",
    "    # ...\n",
    "    @staticmethod\n",
    "    def add_self_follows():\n",
    "        for user in User.select():\n",
    "            if not user.is_following(user):\n",
    "                user.follow(user)\n",
    "    # ...\n",
    "    def save(self, *args, **kwargs):\n",
    "        super(self.__class__, self).save(*args, **kwargs)\n",
    "        if not self.is_following(self):\n",
    "            self.follow(self)\n",
    "```\n",
    "\n",
    "通过覆盖基类的`save()`方法，用户实例在保存时会检查自己是否是自己的关注者， 如果不是，将关注自己。\n",
    "\n",
    "通过`add_self_follows`方法，可以更新数据库中现有的用户关注自身。\n",
    "\n",
    "可通过在 flask shell中运行这个函数来更新数据库：\n",
    "\n",
    "```python\n",
    "(flaskr_env3) $ flask shell\n",
    ">>> User.add_self_follows()\n",
    "```\n",
    "\n",
    "用户关注自己这一功能的实现有一些副作用。因为用户的自关注链接，用户资料页显示的关注者 和被关注者的数量都增加了1个。为了显示准确，这些数字要减去1，直接渲染`{{ user.followers.count() - 1 }}`和`{{ user.followed.count() - 1 }}`即可。还要通过在模板中使用条件语句调整关注用户和被关注用户的列表，不显示自己。\n",
    "\n",
    "**🔖 执行`git checkout 12e`签出程序的这个版本。**"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "name": "python3"
  },
  "name": "12-followers.ipynb"
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
